P07-1. PostgreSQL 내부 구조와 IaC 설계 - 이론 개념

Part 07 (이론): PostgreSQL 18 내부 아키텍처(MVCC · Lock · pg_stat_statements)와 Terraform IaC 설계

핵심 학습 목표

  1. 서비스 메시 엔지니어가 왜 데이터베이스(PostgreSQL)의 커넥션 구조(Process-per-Connection)를 알아야 하는지, 그리고 왜 Istio 서킷 브레이커(maxConnections)와 DB가 맞물려 돌아가는지 이해한다.
  2. PostgreSQL에서 UPDATE를 실행하면 기존 데이터를 덮어쓰지 않고 새 위치(ctid)에 새 줄을 추가하는 MVCC(다중 버전 동시성 제어) 원리와 숨겨진 시스템 컬럼(ctid, xmin, xmax), 그리고 Dead Tuple과 VACUUM 의 관계를 완벽히 설명할 수 있다.
  3. 두 트랜잭션이 같은 행(Row)을 수정할 때 발생하는 Row-Level Lock 경합(FOR UPDATE) 원리와, 장애 시 범인 프로세스를 1초 만에 찾아내는 pg_stat_activity & pg_blocking_pids() 진단법을 익힌다.
  4. 느린 쿼리를 추적하는 pg_stat_statements 확장 모듈과, 이 모든 하이브리드 메시·DB 구성을 코드로 찍어내는 Terraform IaC 설계 원칙을 이해한다.


1. 왜 서비스 메시 아키텍트가 PostgreSQL 내부 구조까지 알아야 할까?

현업에서 마이크로서비스를 운영하다 보면 가장 자주 겪는 장애 상황이 있습니다.
어느 날 갑자기 order-api의 응답 시간이 5초 이상으로 치솟고, Waypoint Proxy에서 504 Gateway Timeout이나 503 Service Unavailable(UO) 알람이 쏟아집니다.

초보 엔지니어는 이 알람을 보고 "서비스 메시 프록시(ztunnel이나 Waypoint)에 버그가 있나? 네트워크가 느린가?" 라며 엉뚱한 곳을 뒤집니다.
하지만 실제로 원인을 파헤쳐 보면 10번 중 8번은 맨 끝에 연결된 데이터베이스(PostgreSQL) 내부에서 벌어진 다음 3가지 사건 때문인 경우가 대부분입니다!

  1. 커넥션 폭주로 인한 DB 메모리 고갈 (Process-per-Connection)
  2. 죽은 데이터(Dead Tuple)가 쌓여 테이블 전체를 뒤지느라 느려진 쿼리 (MVCC & VACUUM 미작동)
  3. 앞선 트랜잭션이 COMMIT을 안 하고 자물쇠(Row Lock)를 쥐고 있어서 뒤따라온 수백 개의 API 요청이 줄줄이 멈춰 서 있는 현상 (idle in transaction & Lock Wait)

따라서 클라우드 아키텍트와 SRE는 네트워크 계층(Service Mesh)과 데이터베이스 내부 계층(PostgreSQL Internals)을 하나의 흐름으로 꿰뚫어 볼 줄 알아야 진짜 근본 원인(Root Cause)을 10초 만에 분리해 낼 수 있습니다.


2. PostgreSQL의 프로세스 구조: 연결 1개당 프로세스 1개 (Process-per-Connection)

MySQL 같은 DB는 하나의 큰 프로그램 안에서 가벼운 스레드(Thread) 여러 개를 만들어 클라이언트 연결을 처리합니다.
반면 PostgreSQL은 연결이 1개 들어올 때마다 아예 리눅스 운영체제 수준에서 독립된 자식 프로세스(postgres 백엔드 프로세스)를 1개씩 통째로 복제(fork)해 띄우는 멀티 프로세스 구조를 사용합니다!

flowchart LR
    subgraph K8S["Kubernetes 클러스터 (오토스케일링!)"]
        P1["order-api 파드 #1
(커넥션 풀 20개)"] P2["order-api 파드 #2
(커넥션 풀 20개)"] PN["... 파드 50개로 확장!
(20 x 50 = 1,000개 연결!)"] end subgraph PG_VM["외부 PostgreSQL 18 VM (study-pg-vm)"] PM["postmaster
(총괄 수문장 프로세스 :5432)"] B1["postgres 백엔드 프로세스 #1 (메모리 ~10MB)"] B2["postgres 백엔드 프로세스 #2 (메모리 ~10MB)"] BN["postgres 백엔드 프로세스 #1000 (메모리 폭발!)"] SHM["Shared Buffers (공유 메모리 캐시) & WAL"] end P1 --> PM P2 --> PM PN --> PM PM -->|"fork()"| B1 PM -->|"fork()"| B2 PM -->|"fork()"| BN B1 --- SHM B2 --- SHM BN --- SHM
용어 정리 — PostgreSQL 핵심 프로세스와 메모리

  1. postmaster (포스트마스터): 5432번 포트를 열고 대기하는 대장 프로세스입니다. 클라이언트가 접속을 요청하면 신원을 확인한 뒤 전용 담당 직원(백엔드 프로세스) 하나를 새로 뽑아서(fork) 연결해 줍니다.
  2. Backend Process (백엔드 프로세스): 클라이언트 연결 1개를 전담 마크하는 프로세스입니다. 프로세스 하나당 기본 약 5MB~10MB 이상의 메모리(work_mem 등) 를 소비합니다.
    • 만약 쿠버네티스 파드가 오토스케일링으로 50개로 늘어났는데 파드마다 DB 연결을 20개씩 맺으면? 순식간에 연결이 1,000개가 되어 PostgreSQL의 기본 한도(max_connections = 100)를 뚫어버리고 FATAL: sorry, too many clients already 에러와 함께 DB가 마비됩니다!
    • 해결책: 앞단에 PgBouncer(커넥션 풀러)를 두거나, Part 05에서 배운 Istio DestinationRule의 maxConnections 서킷 브레이커로 DB VM으로 들어가는 최대 TCP 연결 수를 안전하게 제어해야 합니다!
  3. Shared Buffers (공유 버퍼) & WAL (Write-Ahead Log):
    • 디스크는 느리기 때문에 모든 백엔드 프로세스가 함께 쓰는 거대한 메모리 칠판(Shared Buffers)에 데이터를 올려 두고 읽고 씁니다.
    • 단, 갑자기 정전이 나도 데이터가 날아가지 않도록, 메모리 칠판을 고치기 직전에 "나 몇 번 데이터 이렇게 고쳤다!" 라는 한 줄짜리 일기장(WAL, 선행 기입 로그)을 디스크에 먼저 기록해 복구 가능성을 100% 보장합니다.


3. 핵심 해부 ①: MVCC — 왜 PostgreSQL에서 UPDATE는 사실 DELETE + INSERT 일까?

데이터베이스에서 가장 어려운 문제는 "철수가 1번 주문서를 수정(UPDATE)하고 있는 도중에, 영희가 1번 주문서를 조회(SELECT)하면 어떻게 보여줄 것인가?" 입니다.
만약 철수의 수정이 끝날 때까지 영희의 조회를 못 하게 막아버리면(읽기 잠금), 쇼핑몰 화면이 수시로 멈춰버릴 것입니다.

이를 해결하기 위해 PostgreSQL은 "원본 데이터는 절대 그 자리에서 덮어쓰지 않고, 수정할 때마다 새로운 버전의 행(Tuple)을 옆 칸에 새로 적는다!" 는 MVCC (Multi-Version Concurrency Control, 다중 버전 동시성 제어) 방식을 사용합니다!

flowchart TB
    subgraph BEFORE["1. INSERT 직후 (페이지 0번 블록)"]
        T1["[위치 ctid = (0,1)]
생성자 xmin = 757 | 삭제자 xmax = 0 (살아있음!)
내용: id=1, item='Order v1 - Initial'"] end subgraph AFTER["2. UPDATE 실행 직후 (기존 칸을 덮어쓰지 않고 새 칸 (0,2)에 추가!)"] T1_DEAD["[위치 ctid = (0,1)] -> Dead Tuple (죽은 유령 행!)
생성자 xmin = 757 | 삭제자 xmax = 758 (758번이 지움!)
내용: id=1, item='Order v1 - Initial'"] T2_LIVE["[위치 ctid = (0,2)] -> Live Tuple (새로 태어난 행!)
생성자 xmin = 758 | 삭제자 xmax = 0 (살아있음!)
내용: id=1, item='Order v2 - Updated!'"] T1_DEAD -.->|"새 버전 생성"| T2_LIVE end subgraph VACUUM_AFTER["3. VACUUM (청소부) 실행 후"] T1_FREE["[위치 ctid = (0,1)] -> 빈 공간으로 재활용 가능!"] T2_STILL["[위치 ctid = (0,2)] -> Live Tuple 유지"] end BEFORE --> AFTER --> VACUUM_AFTER
용어 정리 — PostgreSQL의 모든 행에 숨겨진 3개의 비밀 컬럼 (ctid, xmin, xmax)

우리가 테이블을 만들 때 id와 item 컬럼만 만들었더라도, PostgreSQL은 내부적으로 모든 행마다 다음 3개의 숨겨진 관리 번호를 몰래 붙여 놓습니다 (SELECT ctid, xmin, xmax, * FROM 테이블 로 언제든 볼 수 있습니다!):

  1. ctid (물리적 주소 = (페이지번호, 칸번호)):
    • 그 데이터가 디스크(8KB짜리 페이지 블록)의 몇 페이지, 몇 번째 칸에 실제로 적혀 있는지를 나타내는 물리 주소입니다.
    • 처음에 INSERT 하면 (0,1) (0페이지 1번 칸)에 들어갑니다. 그런데 이 행을 UPDATE 하면 주소가 (0,1)에서 (0,2)(0페이지 2번 칸)로 바뀝니다! 즉, 기존 칸을 고친 게 아니라 아예 새 칸에 새로 쓴 것입니다!
  2. xmin (태어난 트랜잭션 번호): 이 버전의 행을 만든(INSERT 또는 UPDATE) 트랜잭션의 고유 번호입니다.
  3. xmax (죽은 트랜잭션 번호):
    • 현재 살아있는 최신 데이터(Live Tuple)는 이 값이 항상 0 입니다.
    • 누군가 이 행을 DELETE 하거나 UPDATE 해서 구버전이 되면, xmax 칸에 그 삭제/수정한 트랜잭션 번호가 찍히면서 죽은 유령 데이터(Dead Tuple) 가 됩니다.
  4. Dead Tuple(데드 튜플)과 VACUUM(베큠 청소):
    • UPDATE를 100만 번 하면 디스크에는 구버전 유령 데이터(Dead Tuple)가 100만 개 쌓여서 테이블 크기가 풍선처럼 부풀어 오르고(Table Bloat) 조회 속도가 급격히 느려집니다.
    • 이 유령 데이터들을 주기적으로 쓸어 담아 빈 공간으로 되돌려주는 청소 작업이 바로 VACUUM (자동으로 돌면 Autovacuum)입니다!

4. 핵심 해부 ②: 왜 DB가 갑자기 멈출까? — Row-Level Lock (FOR UPDATE) 경합

MVCC 덕분에 '읽기(SELECT)'와 '쓰기(UPDATE)'는 서로를 절대 막지 않습니다.
하지만 "쓰기(UPDATE)와 쓰기(UPDATE)", 또는 "수정을 전제로 잠그는 읽기(SELECT ... FOR UPDATE)와 쓰기(UPDATE)" 가 똑같은 행(WHERE id = 1) 에서 부딪히면 어떻게 될까요?

이때는 데이터 정합성을 지키기 위해 먼저 온 트랜잭션이 COMMIT이나 ROLLBACK으로 자물쇠(Row-Level Lock)를 풀 때까지, 뒤에 온 트랜잭션은 그 자리에서 꼼짝 못 하고 무한정 대기(Wait) 합니다!

sequenceDiagram
    autonumber
    participant TxA as 트랜잭션 A (PID 101 · 범인!)
    participant DB as PostgreSQL (id=1 행 자물쇠)
    participant TxB as 트랜잭션 B (PID 205 · 피해자!)

    TxA->>DB: 1. BEGIN;
SELECT * FROM mesh_orders WHERE id = 1 FOR UPDATE; Note over TxA,DB: PID 101이 id=1 행에 독점 자물쇠(Row Lock)를 채움!
그런데 COMMIT을 안 하고 외부 API 호출하느라 10초째 멈춰 있음!
(state = 'idle in transaction') TxB->>DB: 2. UPDATE mesh_orders SET item = 'New' WHERE id = 1; Note over DB,TxB: id=1 자물쇠가 잠겨 있음!
PID 205는 쿼리가 끝나지 않고 그 자리에서 얼어붙음!
(wait_event_type = 'Lock', blocked_by = [101]) TxA->>DB: 3. COMMIT; (자물쇠 해제!) DB-->>TxB: 4. 비로소 PID 205의 UPDATE가 실행되고 응답 반환!
실무 장애 현장에서 범인(Blocker)을 1초 만에 잡는 마법의 함수: pg_blocking_pids(pid)

장애가 터졌을 때 pg_stat_activity 뷰에서 아래 쿼리 한 줄만 실행하면 "누가(blocked_by) 누구(pid)의 멱살을 잡고 안 놔주고 있는지" 가 1초 만에 드러납니다!

SELECT pid, state, wait_event_type, wait_event,
       pg_blocking_pids(pid) AS blocked_by,
       query
FROM pg_stat_activity
WHERE datname = 'ordersdb';
  • 피해자 프로세스: state = 'active', wait_event_type = 'Lock', blocked_by = {101} (101번 때문에 멈춰 서 있음!)
  • 범인 프로세스 (PID 101): state = 'idle in transaction' (트랜잭션을 열어 놓고 자물쇠를 쥔 채 딴짓을 하며 COMMIT을 안 하고 있음!)
  • 긴급 복구 시에는 SELECT pg_terminate_backend(101); 로 범인 세션 하나만 강제 종료하면 밀려 있던 수백 개의 API 요청이 순식간에 뚫립니다! (Part 12 카오스 실습에서 이를 LGTM 대시보드와 함께 실전 수행합니다!)

5. 핵심 해부 ③: 느린 쿼리 블랙박스 pg_stat_statements 와 Terraform IaC

5-1. pg_stat_statements (SQL 성능 블랙박스)

PostgreSQL에서 가장 중요한 공식 확장 모듈입니다. DB 서버에서 실행된 모든 SQL 쿼리의 모양을 정규화하여 총 몇 번 호출되었는지(calls), 총 몇 밀리초를 썼는지(total_exec_time), 1회 평균 몇 밀리초가 걸리는지(mean_exec_time) 를 실시간으로 집계해 줍니다.

5-2. 선언적 인프라 코드화 (Terraform IaC)

지금까지 우리가 Part 01~06에서 구축한 쿠버네티스 네임스페이스, Ambient Mesh 라벨, Waypoint 게이트웨이, 그리고 외부 PostgreSQL VM을 연결하는 WorkloadGroup / WorkloadEntry / ServiceEntry를 실무에서는 손으로 일일이 kubectl apply 하지 않습니다.
Terraform (hashicorp/kubernetes 및 hashicorp/helm 프로바이더) 코드로 작성해 두면:

  1. terraform validate & terraform plan으로 오타와 변경 영향도를 배포 전에 100% 검증할 수 있고,
  2. 새로운 리전이나 고객사 환경(KT Cloud, NCP, AWS)에 똑같은 하이브리드 서비스 메시 아키텍처를 단 1분 만에 복제할 수 있습니다.

🔗 다음 단계 (실습 검증)

이론으로 배운 PostgreSQL 18의 내부 메커니즘을 이제 우리가 Part 06에서 하이브리드 메시에 연결해 둔 study-pg-vm (pg-vm.mesh.internal:5432) 위에서 직접 실험해 봅니다!

  1. UPDATE 전후의 ctid, xmin, xmax 변화와 n_dead_tup 증가, 그리고 VACUUM 청소를 눈으로 확인하고,
  2. 두 개의 세션을 동시에 띄워 SELECT ... FOR UPDATE 행 잠금 경합을 일으킨 뒤 pg_blocking_pids(pid)로 범인 PID를 색출하고,
  3. pg_stat_statements 로 가장 느린 쿼리 1위를 찾아내고 Terraform IaC 코드 검증까지 마칩니다!

네비게이션

이전 목록 다음
◀ Part 06 실습 목록으로 Part 07 실습 ▶